CognitoのJWTをNimbus JOSE + JWTを使って検証する

CognitoのJWTをNimbus JOSE + JWTを使って検証する

Clock Icon2025.01.14

はじめに

独自のアプリケーションでCognitoを用いた認証処理を実装する場合、JWTの検証処理も実装する必要があります。今回は、JavaのJWT関連ライブラリでも広く使われている Nimbus JOSE + JWT を使ってサーバサイドの処理を実装してみます。

https://connect2id.com/products/nimbus-jose-jwt

なお、Developers.IOではNode.jsやPythonで検証する方法を紹介していますので、そちらも合わせてご覧ください。

https://dev.classmethod.jp/articles/aws-jwt-verify-cognito-hono-middleware/

https://dev.classmethod.jp/articles/verify-cognito-with-pyjwt/

前提

各種バージョンは以下の通りです。

  • Java: 21.0.5
  • AWS SDK for Java: 2.29.45
  • Nimbus JOSE JWT: 10.10.1

Cognitoの設定

マネージメントコンソールからCognitoのユーザープールを作成します。デフォルトの設定で作成して問題ないのですが、アプリケーションクライアントの認証フローを変更する必要があります。

アプリケーションクライアントは選択された認証フローしか利用できません。デフォルトでは ALLOW_ADMIN_USER_PASSWORD_AUTH は許可されておらず、これだとサーバサイドでの認証処理を実装できません。これにチェックを入れておきます。

cognito-auth-flow

IAMの設定

サーバサイドでの認証処理を実装する場合、AdminInitiateAuth APIを呼び出すために AdminInitiateAuthAdminRespondToAuthChallenge の許可が必要となります。以下のようなポリシーをIAMに設定すればOKです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "cognito-idp:AdminInitiateAuth",
                "cognito-idp:AdminRespondToAuthChallenge"
            ],
            "Resource": "arn:aws:cognito-idp:*:<AWSカウントID>:userpool/<ユーザープールID>"
        }
    ]
}

実装

今回はJWTの検証がメインなので認証処理の詳細は省略しますが、下記のようにCognitoで認証をしてトークンを取得した、として後続処理を説明していきます。

// 認証パラメータを設定
Map<String, String> authParameters = new HashMap<>();
authParameters.put("USERNAME", username);
authParameters.put("PASSWORD", password);
authParameters.put("SECRET_HASH", secretHash);

// AdminInitiateAuthのリクエストを生成
AdminInitiateAuthRequest authRequest = AdminInitiateAuthRequest.builder()
        .clientId(clientId)
        .userPoolId(userPoolId)
        .authParameters(authParameters)
        .authFlow(AuthFlowType.ADMIN_USER_PASSWORD_AUTH)
        .build();

// AdminInitiateAuthのリクエストを実行
AdminInitiateAuthResponse response = client.adminInitiateAuth(authRequest);

// 結果から各トークンを取得
String idToken = response.authenticationResult().idToken();
String accessToken = response.authenticationResult().accessToken();

署名の検証

JWTを利用するメリットの1つは、トークンの改ざんを検知できることです。CognitoではRSA公開鍵暗号方式を使用して、トークンの署名を検証することで改ざんを検知します。

JWTは {ヘッダ}.{ペイロード}.{署名} というような構成になっています。ヘッダには kid(鍵識別子)alg(署名アルゴリズム) が含まれており、この情報を使って公開鍵を特定します。

{
    "kid": "3QiXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=",
    "alg": "RS256"
}

肝心の公開鍵の取得方法ですが、Cognitoの場合は https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_XXXXXXXXX/.well-known/jwks.json のようなエンドポイントから取得できます。
ここから取得できるデータはJSON配列になっていて、その中から先ほどの kid に紐付くオブジェクトを特定します。

{
  "keys": [
    {
      "alg": "RS256",
      "e": "AQAB",
      "kid": "aM4XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=",
      "kty": "RSA",
      "n": "yhGO8...",
      "use": "sig"
    },
    {
      "alg": "RS256",
      "e": "AQAB",
      "kid": "3QiXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX=",
      "kty": "RSA",
      "n": "qDeRj...",
      "use": "sig"
    }
  ]
}

上記の例だと、2番目の配列のデータが該当しますね。
実際の検証処理としては、まず n(RSA公開鍵のモジュラス値)e(RSA公開鍵の指数値) を使って公開鍵を復元します。その公開鍵を使って署名を検証する、といった感じです。

これを Nimbus JOSE + JWT を用いて実装すると以下のような形になります。なんとなく雰囲気は掴めるのではないかなと思います。

// JSON Web Key Set (JWKS)を取得
String issuer = "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_XXXXXXXXX";
JWKSet jwkSet = JWKSet.load(new URI(issuer + "/.well-known/jwks.json").toURL());

// 署名されたJWT(IDトークン)をパース
SignedJWT signedJWT = SignedJWT.parse(idToken);

// JWKSから、JWTのヘッダーに含まれているkidに対応するJWKを取得
JWK jwk = jwkSet.getKeyByKeyId(signedJWT.getHeader().getKeyID());
if (jwk == null) {
    throw new RuntimeException("no jwk");
}

// 公開鍵を取得し、署名を検証する
RSASSAVerifier verifier = new RSASSAVerifier((RSAPublicKey) jwk.toRSAKey().toPublicKey());
if (!signedJWT.verify(verifier)) {
    throw new RuntimeException("verify failed");
}

クレームの検証

署名の検証を実施することでトークンが改ざんされていないことがわかったので、次に有効期限が切れていないかなどといったクレームの信頼性を確認します。

今回は以下の項目について検証してみます。

  • 有効期限が切れていないか
  • トークンの発行者が正しいか
  • クライアントIDが正しいか
  • トークンの用途が正しいか
// JWTクレームセットを取得
JWTClaimsSet claims = signedJWT.getJWTClaimsSet();

// expクレームの有効期限を検証
if (claims.getExpirationTime().before(new java.util.Date())) {
    throw new RuntimeException("Token expired");
}

// iss クレームの検証
if (!claims.getIssuer().equals(issuer)) {
    throw new RuntimeException("Invalid issuer");
}

// aud クレームの検証
if (!claims.getAudience().contains(clientId)) {
    throw new RuntimeException("Invalid audience");
}

// token_use クレームの検証
if (!claims.getClaim("token_use").equals("id")) {
    throw new RuntimeException("Invalid token_use");
}

ここまできて、「改ざんされていない」且つ「有効な」トークンとして信頼できるようになります。

おわりに

JWTは仕様だけを読んでも理解が難しいものの、ライブラリを用いることで比較的簡単に検証処理を実装することが可能です。
しかし、認証認可の実装不備は脆弱性を生み出し、経済的な損失につながる可能性があります。本質を理解することが実装不備を減らしていく第一歩だと思いますので、この記事が皆さんのお役に立てれば幸いです。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.